๐ ๊ธฐํ ์์ 
๊ธฐ์กด์ ๋ชจ์ผ์ก์ผ๋ก ์ฝ 6๋ช ์ ๋ ์ง์ธ๋ค์๊ฒ ๋ณด์ฌ์ฃผ๊ณ ํผ๋๋ฐฑ์ ๋ฐ์๋ค. ํผ๋๋ฐฑ๋ค ๋๋ถ์ ๋ณด๋ค ๊ฐ๊ด์ ์ผ๋ก ํ๋ก์ ํธ๋ฅผ ๋ณผ ์ ์์๋ค.
๋จผ์  ๋ก๊ทธ์ธ์ ํด์ผ ์ฑ์ฉ๊ณต๊ณ ๋ฅผ ๋ณผ ์ ์๋ค๋ ์ ์ด์๋ค. ๋งจ ์ฒ์ ๋ณผ ์ ์๋ ํ๋ฉด์ด ๋ก๊ทธ์ธ ํ๋ฉด์ด์๊ธฐ ๋๋ฌธ์ ์ฌ์ฉ์ ๊ฒฝํ์ด ์ข์ง ์๋ค๋ ํผ๋๋ฐฑ์ ๋ค์๊ณ , ์ ๊ทน ๊ณต๊ฐํ๋ค. ๋ด๊ฐ ๋ง๋  ์๋น์ค๊ฐ ์ด๋ค ๊ฒ์ธ์ง๋ ๋ชจ๋ฅด๋๋ฐ ๋จผ์  ํ์๊ฐ์
 ํ๋ผ๋ ๊ฒ์ ์ค๋๋ ฅ์ด ์ ํ ์๋ ์์์๋ค.
๋ ๋ฒ์งธ๋ก๋ UI์ ์ผ๋ก ๋๋ฌด ๋น์ด ๋ณด์ธ๋ค๋ ์ ์ด์๋ค. ์ฑ์ฉ๊ณต๊ณ ๊ฐ ๋ง์ผ๋ฉด ๊ทธ๋๋ง ๊ด์ฐฎ์ง๋ง ๋ฉ์ธ ํ์ด์ง๊ฐ ๋๋ฌด ํํด ๋ณด์ธ๋ค๋ ์ ์ด์๋ค. ์ด์ ๋ ๊ณต๊ฐํ๋ ๋ถ๋ถ์ด์๋ค. ์ฑ์ฉ ์๋น์ค๋ค์ ๊ฒฝ์ฐ ๋ค์ํ ์ด๋ฒคํธ๋ค์ ํ๊ณ  ์์ด์ ๋ฐฐ๋๋ก ๋ณด์ฌ ์ฃผ์ง๋ง ํ์ฌ ๋๋ ์ด๋ค ๊ฑธ ๋จผ์  ๋์์ค์ผ ํ  ์ง ๊ณ ๋ฏผ์ด ๋๋ ์ํ๋ค. ๋์ ์ ์ ์ฒด์ ์ธ UI๋ฅผ ์ข ๋ ๋ฐ์  ์์ผ๋ณด๋ ค๊ณ  ์ฑ์ฉ๊ณต๊ณ  ์ฌ์ดํธ๋ค์ ์์๋ค์ ์ฐธ์กฐํ๋ค.
โ ์๋น์ค work flow ์์ ํ๊ธฐ
๋จผ์  ๋ฉ์ธ ํ์ด์ง์์ ์ฑ์ฉ๊ณต๊ณ ๋ค์ ๋ณด์ฌ์ฃผ๊ธฐ ์ํด์๋ ๊ธฐ์กด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ฌ์ฉ์ ๋ณ ๊ถํ์ ์ ๋ฆฌํ ํ์๊ฐ ์์๋ค.
์ ์ฒด ๊ณต๊ณ ๋ ๋ฉ์ธ ํ์ด์ง์์ ๋ฐ๋ก ๋ณผ ์ ์์ด์ผ ํ๋ฏ๋ก ๋ฐ์ดํฐ๋ฒ ์ด์ค์ jobs/์์ ๊ฐ์ฒด๋ก์จ ๋ด๊ฒจ์์ผ๋ฉด ์ข๊ฒ ๋ค๋ ์๊ฐ์ ํ๋ค. ๋ฐฐ์ด๋ก ์ ๋ฆฌํด๋ ๋์ง๋ง ๋ํ
์ผ ํ์ด์ง์์ ์์ธ ๋ด์ฉ์ ๋ณด์ฌ์ค์ผ ํ๋ฏ๋ก, ์ ์ฒด ๋ด์ฉ ์ค ์ํ๋ ์์ธ ๋ด์ฉ์ ์ฐพ์ ๋ ๋ฐฐ์ด๋ณด๋ค ๊ฐ์ฒด์์ ์ฐพ๋ ๊ฒ์ด ์ฑ๋ฅ์ด ๋ ์ข๊ธฐ ๋๋ฌธ์ ๊ฐ์ฒด๋ก ์ ์ฅํ๊ธฐ๋ก ํ๋ค. ์ ์ฒด ๊ณต๊ณ ๋ ์๋น์ค๋ฅผ ์ฌ์ฉํ๋ ๋ชจ๋  ์ฌ๋์ด ๋ณผ ์ ์์ด์ผ ํ์ง๋ง ์์ , ์ญ์ , ์ถ๊ฐ๋ ์ธ์ฆ๋ ์ฌ์ฉ์๋ ๊ด๋ฆฌ์ ๊ถํ์์๋ง ๊ฐ๋ฅํ๊ฒ ๊ตฌ์ํ๋ค.
๋ก๊ทธ์ธ๋ง ํ๋ฉด ๋ชจ๋ ๊ณต๊ณ ๋ฅผ ๋ง์๋๋ก ํ ์ ์๋ ๊ฒ์ด ๊ฑฑ์ ์ด ๋๊ธฐ๋ ํ์ง๋ง, ์๋น์ค์ ๋ฐฉํฅ์, ์๋ก ์ ๋ฆฌํ ๊ด์ฌ ์๋ ํ์ฌ๋ค์ ์ฑ์ฉ๊ณต๊ณ ๋ด์ฉ์ ์ ๋ฆฌํ๊ณ ๊ณต์ ํ ์ ์๋ ์๋น์ค๋ก ๊ตฌ์ํ์ผ๋ฏ๋ก ์ธ์ฆ๋ ์ฌ๋๋ค์ด ๊ด๋ฆฌ์ ๊ถํ๋ ์๊ฒ ํ๋ ๊ฒ์ด ์ข์ ๋ณด์๋ค.
[๊ถํ ๋ณ ๊ฐ๋ฅํ CRUD]
| ๊ถํ | ์ผ๋ฐ ์ฌ์ฉ์ | ์ธ์ฆ๋ ์ฌ์ฉ์ | 
|---|---|---|
์ ์ฒด ๊ณต๊ณ  ( jobs/) | 
GET๋ง ๊ฐ๋ฅ | GET, POST, DELETE, PUT | 
์ ์ ๋ณ ๊ณต๊ณ  ( Users/[user]/jobs ) | 
๋ถ๊ฐ๋ฅ | GET, POST, DELETE, PUT | 
์ ์ฒด๊ณต๊ณ ์ ์ ์ ๋ณ ๊ณต๊ณ ์ ๋ฐ๋ผ ๋ค๋ฅธ API๋ฅผ ๊ตฌํํ๋ ค ํ์ง๋ง ์๋ฒ ์ฌ์ด๋์์ ์ฒ๋ฆฌํด์ ๋ฐ์์ค๋ user๋ฅผ ๋จผ์  ๋ฐ์์ฌ ์ ์์ผ๋ฏ๋ก user์ ์ ๋ฌด๋ก ๊ฐ๊ฐ์ ๊ตฌํํ  ์ ์์ ๊ฒ ๊ฐ๋ค๊ณ  ์๊ฐ๋์๋ค. ๊ทธ๋์ ๊ธฐ์กด DBService interface๋ฅผ ์์ ํ ํ์ react query ์ปค์คํ
 ํ
์ ๋ฐ์ํ๋ค. ๊ทธ๋ฆฌ๊ณ  updateJob๊ณผ addJob์ ํจ์๊ฐ ๋๊ฐ์ firebase์ set์ผ๋ก ๋ง๋ค๊ธฐ ๋๋ฌธ์ ๋์ ํ๋์ ํจ์๋ก ํฉ์ณค๋ค.
// DBType.ts
export interface DBService {
  addOrUpdateJob: (job: Job, user?: User) => Promise<void>;
  getJobs: (user?: User) => Promise<Jobs>;
  removeJob: (job: Job, user?: User) => Promise<void>;
}
//DBService.ts
  async getJobs(user?: User): Promise<Jobs> {
    const dbRef = ref(this.db);
    const query = user ? `users/${user?.id}/` : '';
    return get(child(dbRef, `${query}jobs`))
      .then((snapshot) => {
        if (snapshot.exists()) {
          return snapshot.val();
        } else {
          return {};
        }
      })
      .catch((error) => {
        console.error(error);
      });
  }
  async addOrUpdateJob(job: Job, user?: User) {
    const query = user ? `users/${user?.id}/` : '';
    return set(ref(this.db, `${query}jobs/${job.id}`), job);
  }
  async removeJob(job: Job, user?: User) {
    const query = user ? `users/${user?.id}/` : '';
    return remove(ref(this.db, `${query}jobs/${job.id}`));
  }
}//useJobs.tsx
const JOBS_KEY = "jobs"
export const useJobs = (user?: User) => {
  const dbService = useDBService()
  const queryClient = useQueryClient()
  const { query } = useRouter()
  const { id } = query
  const jobId = typeof id === "string" ? id : id?.join() || ""
  const getJobs = useQuery([JOBS_KEY, user], async () => {
    return dbService.getJobs(user)
  })
  const addOrUpdateJob = useMutation(
    async (job: Job) => {
      return dbService.addOrUpdateJob(job, user)
    },
    {
      onSuccess: () => {
        !user && queryClient.invalidateQueries([JOBS_KEY])
        user && queryClient.invalidateQueries([JOBS_KEY, user])
      },
    }
  )
  const deleteJob = useMutation(
    async (job: Job) => {
      return dbService.removeJob(job, user)
    },
    {
      onSuccess: () => {
        !user && queryClient.invalidateQueries([JOBS_KEY])
        user && queryClient.invalidateQueries([JOBS_KEY, user])
      },
      onError: error => {
        if (error instanceof AxiosError) {
          const { response } = error
          if (response) {
            console.log(response)
          }
        }
      },
    }
  )
  const getFilteredJobs = useQuery(
    [JOBS_KEY, user],
    () => dbService.getJobs(user),
    {
      select: (data: Jobs) => {
        return Object.values(data).filter(item => item.id !== id)
      },
      onError: error => {
        console.error(error)
      },
    }
  )
  const getJobById = useQuery([JOBS_KEY, user], () => dbService.getJobs(user), {
    select: (data: Jobs) => {
      return data[jobId]
    },
    onError: error => {
      console.error(error)
    },
  })
  return { getJobs, addOrUpdateJob, deleteJob, getJobById, getFilteredJobs }
}useJobs์์๋ user๊ฐ ์์ ๊ฒฝ์ฐ ๋ฐ๋ก ๋ฐ์ ์์ผ ํ๋ฏ๋ก react-query API์ key๊ฐ์ผ๋ก user๋ฅผ ํฌํจ ์์ผฐ๋ค. ๊ฒฐ๊ณผ์ ์ผ๋ก user์ ์ ๋ฌด๋ก ์ฒ๋ฆฌํ๋ค ๋ณด๋ ๊ธฐ์กด์ user๊ฐ undefined์ผ ๋๋ฅผ ์ํด ๋ฐ๋ก ์ฒ๋ฆฌํด์ฃผ๋ ๋ก์ง์ ์ ์ธํด ๊น๋ํ๊ฒ ๋ํ๋ผ ์ ์์๋ค.
๊ถํ์ ๋ฐ๋ผ ์ด๋ป๊ฒ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ฒ๋ฆฌํ  ์ง๋ฅผ ์ ํ๊ณ  ๋์ routing์ ๋ํด์๋ ์ ๋ฆฌ๊ฐ ํ์ํ๋ค.
๋จผ์  ์ ์ฒด ๊ณต๊ณ ์ CRUD๋ / ์ /admin ๋ ๊ฐ์ง ํ์ด์ง๋ก ๋๋ด๋ค. /์์๋ ์ ์ฒด ๊ณต๊ณ ๋ฅผ ๋จผ์  ๋ณด์ฌ์ฃผ๊ณ , ๋ก๊ทธ์ธํ ์ ์ ๋ ๊ณต๊ณ ๋ฅผ ์ถ๊ฐํ  ์ ์๊ฒ ๊ตฌ์ํ๋ค. /admin์์๋ ์๋ก์ด ๊ณต๊ณ ๋ฅผ ์ถ๊ฐํ  ์ ์๊ณ  ์ ์ฒด ๊ณต๊ณ ๋ฅผ ์์ , ์ญ์ ํ  ์ ์๊ฒ ํ๋ ค ํ๋ค.
์ ์  ๋ณ ๊ณต๊ณ ์ CRUD๋ ์์ ์ ๋ฆฌํ / ์์ ์ถ๊ฐ ๊ธฐ๋ฅ์ ํ๊ธฐ ๋๋ฌธ์, /user์์๋ ๋ชจ์ ๊ณต๊ณ ๋ฅผ ๋ณด์ฌ์ฃผ๊ณ  ๊ณต๊ณ ๋ฅผ ์์ , ์ญ์ ํ๋ ๊ธฐ๋ฅ์ ์ฒ๋ฆฌํ  ์ ์๊ฒ ๊ตฌ์ํ๋ค.
์ ์ฒด ๊ณต๊ณ ์ ์ ์  ๋ณ ๊ณต๊ณ ๋ ๋ชจ๋ ๋์ผํ JobSection ์ปดํฌ๋ํธ๋ฅผ ํตํด์ ๋ณด์ฌ์ฃผ๊ณ  ์๊ธฐ ๋๋ฌธ์ getServerSideProps๋ก ์ ๋ฌ๋ user์ ์ ๋ฌด๊ฐ ์๋๋ผ path๊ฐ /user์ธ์ง ์๋ ์ง๋ฅผ ๊ธฐ์ค์ผ๋ก useJobs์ user๋ฅผ ์ ๋ฌํด์ค์ผ ํ๋ค.
// JobSection.tsx
export default function JobSection({
  session,
}: {
  session: Session | undefined;
}) {
  const { pathname } = useRouter();
  const isAdmin = pathname === '/admin';
  const title = getTitle(pathname);
  return (
    <Wrapper>
      <header>
        <Title>{title}</Title>
        {isAdmin && (
          <Btn href={'/admin/new'}>
            <AiOutlinePlusCircle />
          </Btn>
        )}
      </header>
      {/* <Filters /> */}
      <JobList session={session} />
    </Wrapper>
  );
}
// JobList.tsx
export default function JobList({ session }: { session: Session | undefined }) {
  const { pathname } = useRouter();
  const isUser = pathname === '/user' || pathname === '/user/[id]';
  const user = session?.user;
  const { getFilteredJobs } = useJobs(isUser ? user : undefined);
  const { isLoading, data: jobs } = getFilteredJobs;
    ...
  return (
    <Wrapper>
      {jobs && jobs.map((job) => <JobItem key={job.id} job={job} />)}
    </Wrapper>
  );
}๋ํ JobItem์ ์ผ๋ฐ ์ฌ์ฉ์๋ ์ถ๊ฐ, ์ญ์  ๋ฒํผ์ ๋ณด์ฌ์ฃผ์ง ์์ง๋ง ๋ก๊ทธ์ธํ ์ ์ ์ ๊ฒฝ์ฐ /์์๋ ์ถ๊ฐ ๋ฒํผ, /user์ /admin ์์๋ ์ญ์  ๋ฒํผ์ด ์ถ๊ฐํด์ผ ํ๊ธฐ ๋๋ฌธ์ ํ์ด์ง ์์น์ ํจ๊ป ๋ก๊ทธ์ธ์ ํ๋์ง ์ ๋ฌด๋ฅผ ๊ณ ๋ คํด์ UI๋ก ๋ํ๋ด ์ฃผ์ด์ผ ํ๋ค. ๋ก๊ทธ์ธ ์ ๋ฌด๋ getServersideProps๋ก ์ ๋ฌ๋ฐ์ session์ ์ ๋ฌํด์ฃผ๊ธฐ ๋ณด๋ค useSession hook์ ์ด์ฉํด CSR์์ ๋ฐ์์ ํ์ธํ๋ค.
/์์ ์ผ๋ฐ ์ฌ์ฉ์: ๋ฒํผ์ด ๋ณด์ด์ง ์์/์์ ๋ก๊ทธ์ธํ ์ฌ์ฉ์: ์ถ๊ฐ ๋ฒํผ์ด ๋ณด์ฌ/user์/admin์์ ๋ก๊ทธ์ธํ ์ฌ์ฉ์: ์ด๋ฏธ ๋ฆฌ๋ค์ด๋ ์ ์ผ๋ก ๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ ํ์ธ์ด ๊ฐ๋ฅํ๊ธฐ ๋๋ฌธ์/ํ์ด์ง๊ฐ ์๋๋ฉด ์ญ์  ๋ฒํผ์ด ๋ณด์ฌ
export default function JobItem({ job }: { job: Job }) {
  const { name, platform, img, checkPercentage } = job
  const { pathname, push } = useRouter()
  const isHome = pathname === "/"
  const [message, setMessage] = useState("")
  const { data: session } = useSession()
  const user = session?.user
  const isLoggedin = !!session
  const { addOrUpdateJob, deleteJob } = useJobs(user)
  const handleDelete = () => {
    deleteJob.mutate(job, {
      onSuccess: () => {
        setMessage("์ฑ๊ณต์ ์ผ๋ก ์ ๊ฑฐํ์ต๋๋ค")
      },
      onSettled: () => {
        setTimeout(() => setMessage(""), 4000)
      },
    })
  }
  const handleAdd = () => {
    addOrUpdateJob.mutate(job, {
      onSuccess: () => {
        setMessage("์ฑ๊ณต์ ์ผ๋ก ์ถ๊ฐํ์ต๋๋ค")
      },
      onSettled: () => {
        setTimeout(() => setMessage(""), 4000)
      },
    })
  }
  const handleClick = () => {
    const link = redirectPath(pathname, job.id)
    push(link)
  }
  const over50Percent = checkPercentage >= 0.5
  return (
    <>
      <Wrapper>
        {over50Percent && <Badge>50% ์ด์</Badge>}
        {!isHome && (
          <Btn onClick={handleDelete}>
            <MdRemove />
          </Btn>
        )}
        {isHome && isLoggedin && (
          <Btn onClick={handleAdd}>
            <AiOutlinePlus />
          </Btn>
        )}
        <ImgBox onClick={handleClick}>
          <Img
            src={img}
            alt="job"
            sizes='(max-width: 768px) 100vw,
              (max-width: 1200px) 50vw,
              33vw"'
            fill
            priority
          />
        </ImgBox>
        <MetaBox>
          <h1>{name}</h1>
          <h3>{platform}</h3>
        </MetaBox>
      </Wrapper>
      {message && <Modal message={message} />}
    </>
  )
}โ ์ ์ฒด ๊ณต๊ณ ๋ฅผ ์ถ๊ฐ,์ญ์ ํ ์ ์๋ adminForm
๊ธฐ์กด์ ํฌ๋กค๋ง ๋ฐฉ์์ ์์ ์ ๊ฑฐํ๋ฉด์ ์๋กญ๊ฒ ์ถ๊ฐ, ์์ ํ ์ ์๋ form ํ์ด์ง๊ฐ ํ์ํ๋ค. form ํ์ด์ง์๋ ๊ธฐ์กด ์ฑ์ฉ๊ณต๊ณ ์ ๋ฐ์ดํฐ schema๋ฅผ ๋ชจ๋ ์์ฑํ ์ ์์ด์ผ ํ๋ค. schema์ ๋ด์ฉ์ ํ์ฌ๋ช , URL, ์ด๋ฏธ์ง, ํ๋ซํผ, ์ฃผ์ ์ ๋ฌด, ์๊ฒฉ ์๊ฑด, ์ฐ๋์ฌํญ์ผ๋ก ์๋์ผ๋ก ์ ์ด์ค์ผ ํ๋ค. ์ฐ์ ์ ์ผ์ผ์ด ์ ๋ ๋ฐฉํฅ์ผ๋ก ์ ํ๋ค. ์ดํ์ ์๋ก์ด ์ถ๊ฐํ๋ ๊ฒฝ์ฐ์๋ input์์ textArea๋ก ๋ณ๊ฒฝํด ๋ณต์ฌ-๋ถ์ฌ๋ฃ๊ธฐ๊ฐ ์ข ๋ ์ฝ๊ฒ ๋ ์ ์๋ ๋ฐฉํฅ์ผ๋ก ๊ณ ๋ฏผํ๊ณ ์๋ค.
์๋ก์ด ๊ณต๊ณ ๋ฅผ ์ถ๊ฐํ๋ ํ์ด์ง๋ /admin/new๋ก ์์ ํ  ๋๋ /admin/[id]๋ก ์์ ์ด ๋  ์ ์๊ฒ routing์ ๊ฒฐ์ ํ๋ค. AdminForm ์ปดํฌ๋ํธ ๋ด์ ํจ์๋ค์ด ๋ง์ ์ปค์คํ
 hook์ผ๋ก ๋ถ๋ฆฌํ๋ค. message ์ํ ๊ฐ์ ๊ฒฝ์ฐ mutate์ ํญ์ ๊ฐ์ด ๋ฐ๋ผ ๋ค๋๊ธฐ ๋๋ฌธ์ ์ฌ๋ฌ ์ปดํฌ๋ํธ์ ๋์ผํ๊ฒ ๋ํ๋, ์ด๋ป๊ฒ ํ๋ฉด ๋ฐ๋ณต์ ์ค์ผ ์ ์์์ง ์ข ๋ ๊ณ ๋ฏผ์ด ํ์ํ๋ค.
export default function AdminForm({ isNew, initialValue }: AdminFormProps) {
  const { job, onAdd, onChange, onDelete, onUpdateDescription } =
    useForm(initialValue)
  const [message, setMessage] = useState("")
  const DescriptionList: DescriptionListType[] = [
    {
      name: JOB_SCHEMA.MAIN_WORK,
      title: "์ฃผ์ ์
๋ฌด",
      value: job.mainWork,
    },
    {
      name: JOB_SCHEMA.QUALIFICATION,
      title: "์๊ฒฉ ์๊ฑด",
      value: job.qualification,
    },
    {
      name: JOB_SCHEMA.PREFERENTIAL,
      title: "์ฐ๋ ์ฌํญ",
      value: job.preferential,
    },
  ]
  const title = isNew ? "์๋ก์ด ๊ณต๊ณ  ์ถ๊ฐํ๊ธฐ" : "๊ณต๊ณ  ์์ ํ๊ธฐ"
  const BtnText = isNew ? "์ถ๊ฐํ๊ธฐ" : "์์ ํ๊ธฐ"
  const { addOrUpdateJob } = useJobs()
  const { mutate } = addOrUpdateJob
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const { dataset } = e.currentTarget
    if (dataset.tag !== "form") {
      return
    }
    mutate(job, {
      onSuccess: () => {
        setMessage(
          isNew ? "์ฑ๊ณต์ ์ผ๋ก ์ถ๊ฐ๋์์ต๋๋ค" : "์ฑ๊ณต์ ์ผ๋ก ์์ ๋์์ต๋๋ค"
        )
      },
      onError: error => {
        if (error instanceof AxiosError) {
          const { response } = error
          if (response) {
            setMessage(`${response.statusText} ์๋ฌ๊ฐ ๋ฐ์ํ์ต๋๋ค`)
          }
        }
      },
      onSettled: () => {
        setTimeout(() => {
          setMessage("")
        }, 4000)
      },
    })
  }
  return (
    <Wrapper>
      ...
      <form data-tag="form" onSubmit={handleSubmit}>
        <AdminFormItem
          name={JOB_SCHEMA.NAME}
          title="ํ์ฌ ๋ช
"
          type="text"
          value={job.name}
          onChange={onChange}
        />
        <AdminFormItem
          name={JOB_SCHEMA.URL}
          title="URL"
          type="text"
          value={job.url}
          onChange={onChange}
        />
        <AdminFormItem
          name={JOB_SCHEMA.IMG}
          title="์ด๋ฏธ์ง"
          type="text"
          value={job.img}
          onChange={onChange}
        />
        <Select onChange={onChange} platform={job.platform} />
        {DescriptionList.map(item => (
          <AdminDescriptionList
            name={item.name}
            title={item.title}
            value={item.value}
            onAdd={onAdd}
            onDelete={onDelete}
            onChange={onUpdateDescription}
          />
        ))}
        <Btn>{BtnText}</Btn>
      </form>
      {message && <Modal message={message} />}
    </Wrapper>
  )
}
//useForm.tsx
export const useForm = (initialValue: Job) => {
  const [job, setJob] = useState<Job>(initialValue)
  const onAdd = (name: DescriptionNameType) => {
    setJob(prev => {
      const list = prev[name]
      const newItem: DescriptionType = { text: "", checked: false, id: uuid() }
      return { ...prev, [name]: [...list, newItem] }
    })
  }
  const onDelete = (name: DescriptionNameType, id: string) => {
    setJob(prev => {
      const list = prev[name].filter(item => item.id !== id)
      return { ...prev, [name]: list }
    })
  }
  const onChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    const { name, value } = e.currentTarget
    setJob(prev => ({ ...prev, [name]: value }))
  }
  const onUpdateDescription = (
    name: DescriptionNameType,
    value: string,
    id: string
  ) => {
    setJob(prev => {
      const updated = prev[name].map(item => {
        if (item.id === id) {
          return { ...item, text: value }
        }
        return item
      })
      return { ...prev, [name]: updated }
    })
  }
  return { job, onAdd, onDelete, onChange, onUpdateDescription }
}์์ ๊ฐ์ด ๊ธฐํ์ ์์ ํ ํ์ ํํ์ด์ง๋ฅผ ๊ตฌ์ฑํ์ ๋ ๋ค์๊ณผ ๊ฐ์ด ๋ํ๋ฌ๋ค.
[ํํ์ด์ง ( /) , ์ ์ฒด ๊ณต๊ณ ์ ์์ธ ํ์ด์ง (/jobs/:id) ]
  
    
    
[์ ์  ๋ณ ํ์ด์ง ( /user ), ์ ์ ๋ณ ์์ธ ํ์ด์ง (/user/:id) ]
  
    
    
[admin ํ์ด์ง ( /admin ), admin ์์ธ ํ์ด์ง (/admin/:id) ]
๐จ ๋์์ธ ์์ 
๋์์ธ์ ๋ํ ํผ๋๋ฐฑ์ ๋ฐ๊ณ ์ฒ์์๋ ์ ํด๋๋ค๊ณ ์๊ฐํ์๋๋ฐ ๋น์ด ๋ณด์ธ๋ค๋ ์๊ฐ์ด ๋ง์ด ๋ค์๋ค. ๋จผ์  ์๊ฐ์ด ๋ ๋ถ๋ถ์ ๋ฐฐ๊ฒฝ๊ณผ ์ปจํ ์ธ ์ ์์ด ๋๊ฐ๊ธฐ ๋๋ฌธ์ ๋น์ด ๋ณด์ด๋ ๊ฒ ํฌ๋ค๋ ์๊ฐ์ด ๋ค์ด์ ์ปจํ ์ธ ์ ๋ฐฐ๊ฒฝ ์์ ๊ตฌ๋ถํ๊ณ ๊ธฐ์กด ๋ฉ์ธ ํ์ด์ง๋ถํฐ ์์๋๋ก ์๋ณด๊ธฐ ์์ํ๋ค.
๋ฉ์ธ ํ์ด์ง
๊ธฐ์กด์ ๋ฉ์ธ ํ์ด์ง๋ ํฌ๋กค๋ง์ ํ ์ ์๋ form์ด ์์ด์ ์ปจํ ์ธ ๊ฐ ๋ง์ด ์์ด๋ ๊ด์ฐฎ์์ง๋ง form์ด ์ฌ๋ผ์ง๊ณ ๋ ๋ค์๋ ํ์ ํจ์ด ๋ ์ปค ๋ณด์๋ค. ๊ทธ๋์ ์ฐ์ ์ ํ์ ์๋ ๋ฐฐ๋๋ ์์ ๊ณ ๊ฐ jobItem์ ํฌ๊ธฐ๋ฅผ ํค์์ ๋ณด๋ค ๊ณต๊ณ ๊ฐ ์ ๋ณด์ด๊ฒ ์์ ํ๋ค. ์ฌ๊ธฐ์ ํํ์ด์ง ๋์์ธ์ ์ถ๊ฐํ๋ค๋ฉด ๋ชจ๋ ๊ณต๊ณ ๋ก ํ์ด์ง๋ฅผ ๋ถ๋ฆฌํ๊ณ ๋ฉ์ธ ํ์ด์ง์์ ์ฌ์ฉ๋ฒ์ ๋ํด์ ๋ํ๋ด ์ฃผ๋ฉด ์ข ๋ ์ข์ง ์์๊น ์๊ฐ๋ ๋ค์๋ค. ๊ณต๊ณ ๊ฐ ๋ง์์ง๋ฉด ํ์ด์ง๋ค์ด์ ์ด๋ ๋ฌดํ ์คํฌ๋กค์ ์ถ๊ฐํด์ ๊ธฐ๋ฅ์ ์ธ ๋ณด์ถฉ๋ ํ์ํ ๊ฒ ๊ฐ๋ค.
[๋ฉ์ธ ํ์ด์ง]
  
    
    
์์ธ ํ์ด์ง
๊ธฐ์กด ์์ธ ํ์ด์ง์ ๋ฌธ์ ์ ์ ์ฌ์ง๊ณผ ์ฑ์ฉ๊ณต๊ณ ์ ์ฃผ์์ ๋ฌด๋ฅผ ํ ์ค์ ๋ํ๋ด๋ค ๋ณด๋ ์ฌ์ง์ ํฌ๊ธฐ๋ ์ฃผ์ ์ ๋ฌด์ ์์ ๋ฐ๋ผ ๋ ์ด์์์ ๋ฌธ์ ๊ฐ ์๊ฒผ๋ค. ์ด๊ฒ์ ํด๊ฒฐํ๊ธฐ ์ํด์ ์ฐ์ ์ ๋ชจ๋ ์ธ๋ก๋ก ๋ ์ด์์์ ๋ณ๊ฒฝํ๊ณ , ์ด๋ฏธ์ง ํฌ๊ธฐ๋ ๊ณ ์  ์์ผ๋์๋ค. ์ด๋ ๊ฒ ๋ํ๋ด๋ฉด ๊ฐ๋ก๋ก ๋๋ฌด ๋น๊ฒ ๋๋ ๊ฒ ๋ฌธ์ ๊ฐ ์๊ฒผ๋๋ฐ, ์ด์ ์ ํ๋ก๊ทธ๋๋จธ์ค์ ์ํฐ๋ ํ์ด์ง๋ฅผ ์ฐธ๊ณ ํด Sidebox ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํ๋ค. Sidebox๋ sticky๋ฅผ ์ด์ฉํด ์ฌ์ฉ์์ ์คํฌ๋กค ์์น์ ์๊ด์์ด ๊ณ์ํด์ ๋ณด์ฌ ์ฃผ๋ฉด ์ข์ ๊ฒ ๊ฐ์ ํ์ฌ์ด๋ฆ๊ณผ ์ถ๊ฐ ๋ฒํผ์ ๋ด์๋ค.
[ํ๋ก๊ทธ๋๋จธ์ค ์ฑ์ฉ๊ณต๊ณ  ํ์ด์ง]
  
    
    
[์์ ํ ์์ธ ํ์ด์ง ]

AdminForm
AdminForm์ผ๋ก ๋ช ๊ฐ์ ์ฑ์ฉ๊ณต๊ณ ๋ฅผ ์ง์  ์ฌ๋ฆฌ๋ฉด์ ํ์ฌ ๋์์ธ์ ๊ณต๊ณ ์ ๋ด์ฉ์ ๋ด๊ธฐ์ ๊ฐ๋
์ฑ๋ ๋จ์ด์ง๊ณ  ์ผ์ผ์ด ์ฎ๊ฒจ์ผ ํ๋ ๋ถํธํจ๋ ์์๋ค. ์ด์ ์ ํด๊ฒฐํ๊ธฐ ์ํด์ ๋์์ธ์ ์์ธ ํ์ด์ง ๋์ ๊ฐ์ด ์ธ๋ก๋ก ๋ ์ด์์์ ๋ฐ๊พธ๊ณ , ํญ๋ชฉ ํ๋ํ๋๋ฅผ ๋ด๋ <input/>์ด ์๋๋ผ ํต์ผ๋ก ๋ณต์ฌ, ๋ถ์ฌ๋ฃ๊ธฐ ํ  ์ ์๊ฒ <textarea/>๋ก ํ๊ทธ๋ฅผ ๋ฐ๊ฟ์ ํธ์์ฑ์ ๋์๋ค. ๊ธฐ์กด input์ผ๋ก ํ์
๊ณผ ๋ชจ๋  ํจ์๋ฅผ ์ง๋จ๊ธฐ ๋๋ฌธ์ ์ํ๋ฅผ ๋ฐ๋ก ์ถ๊ฐํด์ ์ดํ์ submitํ  ๋ normalize์ํค๋ ๋ฐฉํฅ์ผ๋ก ์์
์ ์งํํ๋ค.
value๊ฐ์ด textArea๋ string์ด๊ณ ๊ธฐ์กด ๋ฐ์ดํฐ๋ ๋ฐฐ์ด์ด์ด์ type์ ์ด์ฉํด ๋ก์ง์ ๊ตฌ๋ถ ์ํฌ ์ ์์๋ค.
// AdminDescriptionList.tsx
export default function AdminDescriptionList({
  name,
  title,
  value,
  onAdd,
  onDelete,
  onChange,
  onNewDescriptionChange,
}: AdminDescriptionListProps) {
  const isString = typeof value === "string"
  return (
    <Wrapper>
      ...
      {isString && (
        <TextArea
          text={value}
          name={name}
          onChange={onNewDescriptionChange}
        ></TextArea>
      )}
      {!isString && (
        <ul>
          {value.map(item => (
            <AdminDescriptionItem
              key={item.id}
              name={name}
              item={item}
              onChange={onChange}
              onDelete={onDelete}
            />
          ))}
        </ul>
      )}
    </Wrapper>
  )
}
// TextArea.tsx
export default function TextArea({ name, text, onChange }: TextAreaType) {
  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const { value } = e.currentTarget
    onChange(name, value)
  }
  return <Wrapper required value={text} onChange={handleChange}></Wrapper>
}ํฌ๋กค๋ง์ผ๋ก ๋ฌธ์์ด์ ์ฒ๋ฆฌํ  ๋๋ ๋๋ฌด ๋ง์ ์ ์ฝ์ด ์์์ง๋ง ์ง์  ๋ณต์ฌ, ๋ถ์ฌ ๋ฃ๊ธฐ๋ฅผ ํ๋ค๊ณ  ํ์ ๋๋ ํด๋น ๋ถ๋ถ์ ๋ด์ฉ๋ง ๊ฐ์ ธ์ค๋ฉด ๋์, โข ์ -๋ฅผ ์ ๊ฑฐํด์ฃผ๊ณ  ๋์ด์ฐ๊ธฐ๋ก split๋ง ํ๋ฉด ๊ฐ๋จํ๊ฒ ๋ฐ์ดํฐ๋ฅผ ๊ฐ๊ณตํ  ์ ์์๋ค. ์ด๋ ๊ฒ ๊ฐ๊ณต๋ ๋ฐ์ดํฐ๋ฅผ ๊ธฐ์กด handleSubmit๊ณผ ์ฐ๊ฒฐํ  ๋, normalizeํ ๋ฐ์ดํฐ๋ฅผ ๋ค์ setJob์ผ๋ก ์
๋ฐ์ดํธ ์ํค๋ ๊ฒ ์๋๋ผ ๊ทธ๋๋ก ๊ฐ์ ์ฌ์ฉํด์ API๋ฅผ ํธ์ถํ๋ค. ์ด๋ ๊ฒ ์ฒ๋ฆฌํ ์ด์ ๋ setState๋ก ์ํ๋ฅผ ์
๋ฐ์ดํธํ๊ณ  mutate๋ฅผ ํ๋ฉด ๋๊ธฐ์  ์ฝ๋์ง๋ง setState๊ฐ ๋น๋๊ธฐ์ ์ผ๋ก ์ฒ๋ฆฌ๋  ์ ์๊ธฐ ๋๋ฌธ์ด์๋ค.
// normalizeDescription.ts
type RawDescriptionsType = {
  mainWork: string;
  qualification: string;
  preferential: string;
};
type NormalizedDescriptionsType = {
  mainWork: DescriptionType[];
  qualification: DescriptionType[];
  preferential: DescriptionType[];
  [index: string]: DescriptionType[];
};
export const normalizeDescriptions = (
  descriptions: RawDescriptionsType
): NormalizedDescriptionsType => {
  const result: NormalizedDescriptionsType = {
    mainWork: [],
    qualification: [],
    preferential: [],
  };
  const reg = /[โข-]/gi;
  for (const [key, value] of Object.entries(descriptions)) {
    const text = value.replace(reg, '').trim();
    const items = text.split('\n');
    const normalizedItems = items.map((item) => {
      return { id: uuid(), text: item, checked: false };
    });
    result[key] = normalizedItems;
  }
  return result;
};
// AdminForm.tsx
 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const { dataset } = e.currentTarget;
    if (dataset.tag !== 'form') {
      return;
    }
    let targetJob = job;
    if (isNew) {
      const normalizedDescriptions = normalizeDescriptions(descriptions);
      targetJob = { ...job, ...normalizedDescriptions };
    }
    mutate(targetJob, {...})
  };[์์ ํ ์ ์ฒด๊ณต๊ณ  ์ถ๊ฐ ํ์ด์ง (/admin/new)]

๋ง์น๋ฉฐ
์์ง ํ๊ณ  ์ถ์ ๊ฒ๋ ๋ถ์กฑํ ๊ฒ๋ ๋ง์ ํ๋ก์ ํธ๋ผ ๋งค๋ฒ ์๋ก์ด ์๋๋ค์ ํ  ๋ ์ฆ๊ฒ๋ค. ๋ฌผ๋ก  ํ์ค์ ์ด๋ ฅ์๋ฅผ ์ฐ๊ณ  ๋จ์ด์ง๋ ๋ ๋ค์ ์ฐ์์ด์ง๋ง ๋ฌด์กฐ๊ฑด ๊ฐ๋ฐ์๊ฐ ๋๋ค๋ ์๊ฐ์ผ๋ก ์ง๊ธ ๋ด๊ฐ ์๋ ์๋ฆฌ์์ ๋ ์ํ  ์ ์๋ ๋ฐฉ๋ฒ๋ค์ ๋ฐ์ํ๋ค ๋ณด๋ฉด ์ ๋ง ์ํ๋ ํ์ฌ์์ ๋ด๊ฐ ์ํ๋ ์๋น์ค๋ฅผ ๋ง๋ค๊ณ  ์์ง ์์๊น. ๋ด์ผ ๋๋ฅผ ํ๋ฒ ๋ ๋ฏฟ์ด๋ณด๊ฒ ๋ค๋ ๋ง์ผ๋ก ๊ฐ๋ฐ์ ์ฆ๊ธฐ๋ฉฐ ์ด ์๊ฐ์ ๋ฒํ
จ๋๊ฐ๋ ค ํ๋ค.